Skip to content

Add an optional class filter to JavaSerializer deserialization - #1295

Open
Nexory wants to merge 2 commits into
EsotericSoftware:masterfrom
Nexory:hardening/javaserializer-classfilter
Open

Add an optional class filter to JavaSerializer deserialization#1295
Nexory wants to merge 2 commits into
EsotericSoftware:masterfrom
Nexory:hardening/javaserializer-classfilter

Conversation

@Nexory

@Nexory Nexory commented Aug 3, 2026

Copy link
Copy Markdown

What / Why

JavaSerializer.read(...) deserializes with ObjectInputStream.readObject(), and its ObjectInputStreamWithKryoClassLoader overrides only resolveClass(...) for classloader resolution. It applies no restriction on which classes may be deserialized. Kryo's own setRegistrationRequired(true) does not help on this path, because the bytes are handed to the JDK serialization mechanism rather than resolved through Kryo's class registration, so a Kryo stream that uses JavaSerializer for any field type deserializes arbitrary classes from the input.

This adds an opt-in class filter to JavaSerializer as defense-in-depth when reading serialized data from an untrusted source:

  • setClassFilter(Predicate<Class>) / getClassFilter().
  • The filter is checked in resolveClass(...): a class for which the predicate returns false is rejected with a KryoException before it is used.

Non-breaking

The default is unchanged. With no filter set (null, the default) behavior is exactly as before, and the existing classloader-resolution logic in resolveClass(...) is preserved.

Java 8 note

Kryo targets Java 8, so this uses a Predicate<Class> checked in the existing resolveClass(...) hook rather than the Java 9+ java.io.ObjectInputFilter (JEP-290). If you prefer, I am happy to additionally wire a real ObjectInputFilter on Java 9+ (resolved reflectively so the Java 8 build is unaffected), or to expose a Set<String> allowlist instead of a predicate. Whichever shape you prefer, the check stays in one place.

Tests

JavaSerializerTest adds two cases: a filter that disallows the class rejects deserialization (as KryoException), and a filter that allows it still round-trips. The existing tests are unchanged. Verified with mvn -Dtest=JavaSerializerTest test on JDK 11 (source/target 8).

JavaSerializer.read deserializes with ObjectInputStream.readObject and
previously applied no restriction on which classes may be deserialized;
Kryo's setRegistrationRequired does not apply on this path because the
bytes are handed to the JDK serialization mechanism. Add an opt-in
setClassFilter(Predicate<Class>), checked in resolveClass, so a class for
which the predicate returns false is rejected with a KryoException before
it is used. The default is unchanged (no filter) and the existing
classloader resolution is preserved.
@theigl

theigl commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Hi @Nexory. Thanks for the PR. The issue I see is that your filter is applied to late. It does Class.forName and then* checks if the class is allowed. The potentially malicious class is already loaded at this point. The check would need to be done before class loading and match on the class name.

Kryo 6 will move to Java 17 or 21 and we could replace this with an ObjectInputFilter then.

Applied to the resolved Class, the filter could not refuse a name that does not
resolve at all, and it let the class be loaded first. It now runs on the name as
the first thing resolveClass does, so a refused class is never loaded.

The filter type changes from Predicate<Class> to Predicate<String> accordingly.
Measured with -verbose:class on a gadget nested inside an allowed outer type:
with the filter refusing, the class does not appear in the load list and its
static initializer does not run; with it allowing, both happen.
@Nexory

Nexory commented Aug 20, 2026

Copy link
Copy Markdown
Author

You are right that the check belongs before the class is resolved, and it now is: the filter
runs on type.getName() as the first thing resolveClass does, so a refused class is never
loaded and a name that does not resolve at all is refused rather than reported as missing. The
type changes from Predicate<Class> to Predicate<String> accordingly.

One correction to the reasoning, because it affects how urgent this was. The old code called
Class.forName(name, false, loader), and that false means do not initialize. Measured with a
class whose static initializer prints: resolving it that way does not run the initializer,
while Class.forName(name) does. So the class was being loaded but no attacker code ran at
that point. The change is still the right one, for the reasons above.

Measured with -verbose:class, on a gadget nested inside an allowed outer type, since that is
where the filter applies: with the filter refusing, the class does not appear in the load list
and its initializer does not run; with the filter allowing, both happen. The reader never names
the class and the bytes come from a separate JVM.

Worth noting for the Kryo 6 plan: this is the same shape ObjectInputFilter uses, a decision
on the name before resolution, so swapping the implementation later should be small.

I followed your PR workflow: build on JDK 11, then mvn -B test on 8 and on 17. 310 and 330
tests, no failures. I did not run the 11, 21, 25 and 26 legs.

@theigl

theigl commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

One correction to the reasoning, because it affects how urgent this was. The old code called
Class.forName(name, false, loader), and that false means do not initialize. Measured with a
class whose static initializer prints: resolving it that way does not run the initializer,
while Class.forName(name) does. So the class was being loaded but no attacker code ran at
that point. The change is still the right one, for the reasons above.

You are right! I overlooked the false parameter. Now I'm not so sure anymore what the ideal shape of the filter should be. ObjectInputFilter can be constructed from a String pattern as well as a Predicate<Class>, but the final check is always against a FilterInfo which exposes a Class. So arguably, a Predicate<Class> is closer to ObjectInputFilter.

It's unfortunate that we are still on JDK8 and can't go for ObjectInputFilter directly.

What's your opinion? Should we revert back to your original version, or should we go with String?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants